Skip to content

fix(qwp): drop cached timestamp columns and repair accounting on buffer clear/rollback - #77

Open
jerrinot wants to merge 2 commits into
mainfrom
jh_at_fixes
Open

fix(qwp): drop cached timestamp columns and repair accounting on buffer clear/rollback#77
jerrinot wants to merge 2 commits into
mainfrom
jh_at_fixes

Conversation

@jerrinot

Copy link
Copy Markdown
Contributor

A QWP/WebSocket sender could become unusable after rejecting an oversized row that introduced the table’s designated timestamp column. The rollback removed the column, but the sender kept a cached reference to it, so the next timestamped row failed with a NullPointerException.

The sender now discards cached timestamp state when a row is rolled back, allowing ingestion to continue after an oversized-row error. Clearing a sender-owned QwpTableBuffer through the test-only accessors now performs equivalent cleanup and reconciles WebSocket row/byte accounting and UDP datagram-size estimates, so the buffer can be reused safely.

…er clear/rollback

Senders cache designated-timestamp ColumnBuffer refs and derive
bookkeeping from their table buffers. rollbackUncommittedColumns()
(failed at()) and QwpTableBuffer.clear() close those columns, leaving
dangling refs that NPE on the next at() and corrupt pending accounting.

- Invalidate WebSocket timestamp caches in rollbackRow(); cancelRow()
  now routes through it.
- Add QwpTableBuffer.Owner: clear() notifies the owning sender before
  releasing columns. WebSocket subtracts the buffer's exact accounted
  share (committedBytes - new baselineBytes, seed offsets excluded);
  UDP resets transient row state, datagram estimate, and headroom
  state (its base-estimate cache is keyed by column count alone).
- Unify flush snapshot anchoring at post-reset storage so per-table
  contributions are exact under any reset/switch/flush history.

Hot path unchanged. Regressions: clear between rows (both units, both
senders), non-current/mid-row clear, accounting locality, stale
headroom estimate.
@jerrinot jerrinot added the bug Something isn't working label Jul 29, 2026
@jerrinot

Copy link
Copy Markdown
Contributor Author

All four review agents plus my own trace converge: no correctness, concurrency, resource, performance, or accounting defect. The fresh-context adversarial agent independently ran the suites (108 tests) plus a custom probe of the one untested path and found nothing. Here is the verified report.


Review: PR #77fix(qwp): drop cached timestamp columns and repair accounting on buffer clear/rollback

Level 3 (full pass: 13 agent roles, per-finding source verification). No committed binaries. Both changed test suites pass locally (QwpWebSocketSenderTest 48/48, QwpUdpSenderTest 60/60, 0 skipped).

Critical

None.

Moderate

None. One latent, non-reachable observation is recorded under Minor.

Minor

  1. Test method ordering violates the alphabetical convention (in-diff).

    • QwpWebSocketSenderTest.java:170,175testAtMicros/AtNanosRecoversAfterTableBufferClear are placed after testAtNowAfterCloseThrows:155; alphabetically both belong before it. :313,318 — the two testAt*RecoversAfterOversizeRowRollback sit after the testClearOf* block (before testBinaryColumnAfterCloseThrows:323), out of order.
    • QwpUdpSenderTest.java:283testTableBufferClearInvalidatesHeadroomBaseEstimate is wedged between testAt* methods.
    • Fix: move the six new testAt* methods into the testAt* run and keep the testClearOf* group together and internally sorted. Pure cosmetics; the skill's test-code rules do apply member ordering to tests.
  2. firstPendingRowTimeNanos can point at removed rows after clearing a non-current buffer (QwpWebSocketSender.java:2274-2276). onTableBufferClear only resets the timer when pendingRowCount reaches exactly 0. If the cleared buffer held the oldest pending rows while other tables still have pending rows, the interval auto-flush may fire slightly early — strictly safe-side, never late, never data loss. Not reachable in production (production clear() runs only at sender close(); between-batch reuse goes through reset()), so this is a hardening note, not a bug. Would become live only if a future change invoked clear() on a live sender.

  3. Missing Fixes #NNN in the PR body. CLAUDE.md wants it at the top when an issue exists; fine if none was filed.

  4. Optional coverage gaps (correct by construction, untested): (a) WS oversize rollback when committed rows already exist and the rejected row introduces a new non-timestamp column (the surviving-ts, cache-relookup path); (b) clear() of the current buffer holding committed and in-progress rows simultaneously.

Downgraded (candidate concerns verified false)

  • pendingBytes/pendingRowCount drift or negativity after clear/reset/rollback interleavings — false. pendingBytes == Σ(committedBytes − baselineBytes) telescopes from the same anchor sendRow() uses; committedBytes ≥ baselineBytes always; clear() is idempotent. Verified across double-clear, current-buffer-with-in-progress-row, post-flush, and switch-away-and-back sequences.
  • baselineBytes goes stale when a column is created after reset() — false. The delta accounting only depends on current − baseline; rolled-back columns are always post-reset (index ≥ committedColumnCount) so never part of baseline.
  • Dangling cached ColumnBuffer after rollback (regular columns / UDP in-progress state) — false. WS caches only the two timestamp columns (both nulled); regular columns are looked up per value; UDP InProgressColumnState.clear() nulls column (QwpUdpSender.java:1462).
  • Constructor overload ambiguity / symbol-dict regression — false. (String, QwpWebSocketSender) binds for WS (global dict), (String, Owner) for UDP (local dict, identical to the pre-PR sender=null); the (QwpWebSocketSender) null cast disambiguates the 1-arg ctor.
  • clear() reachable on a live sender / callback stale reads — false. Only reached via close(); callback fires before any state is released.
  • Perf regression from the extra getBufferedBytes() walks in reset()/callback — false. Off the per-row path; O(columns) lost in the flush's existing O(columns×rows) encode.

Summary

Approve. This is a correct, tightly-scoped fix for a genuine native use-after-free: an oversized row that introduces a table's designated-timestamp column had that column closed by rollbackUncommittedColumns(), leaving cachedTimestampColumn pointing at freed off-heap memory so the next at() would addLong() into it. rollbackRow()/cancelRow() now null the caches, and the new Owner callback keeps byte/row accounting consistent when a buffer is cleared. The resetTableBuffersAfterFlush snapshot re-anchor (:3870) is an incidental but valid fix for a pre-existing over-count of var-width seed bytes in the auto-flush byte threshold.

  • Draft findings: 6 candidate concerns verified and dropped as false positives; 4 minor items kept (1 style, 1 latent/unreachable, 1 metadata, 1 optional coverage). 0 Critical, 0 Moderate.
  • In-diff vs out-of-diff: all kept findings are in-diff. The cross-context pass (Agent 9) walked every callsite of the changed symbols (clear, the constructors, rollbackRow, getTableBuffer, onTableBufferClear) and confirmed production clear() is close-only, so zero out-of-diff findings is correct here, not an under-run.
  • Regression tests are genuine: all three helpers were traced to fail (NPE or wrong count/estimate) if the fix is reverted.

Track the bounded creation wait and handled interrupts in one atomic test witness. Handshake repeated interrupts until the original wait exits so the regression tests reject deadline resets without depending on scheduler timing.
@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 71 / 72 (98.61%)

file detail

path covered line new line coverage
🔵 io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java 20 21 95.24%
🔵 io/questdb/client/cutlass/qwp/client/QwpUdpSender.java 15 15 100.00%
🔵 io/questdb/client/impl/QueryClientPool.java 12 12 100.00%
🔵 io/questdb/client/impl/SenderPool.java 12 12 100.00%
🔵 io/questdb/client/cutlass/qwp/protocol/QwpTableBuffer.java 12 12 100.00%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants